auditrum 0.3.0__tar.gz → 0.3.2__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.
- {auditrum-0.3.0 → auditrum-0.3.2}/PKG-INFO +8 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/README.md +1 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/blame.py +6 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/cli.py +14 -3
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/context.py +7 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/executor.py +7 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/hardening.py +13 -2
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/hash_chain.py +11 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/__init__.py +7 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/admin.py +7 -4
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/apps.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/audit.py +5 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/executor.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/commands/audit_add_partitions.py +4 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/middleware.py +5 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/mixins.py +26 -3
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/models.py +13 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/operations.py +5 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/runtime.py +5 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/settings.py +5 -0
- auditrum-0.3.2/auditrum/integrations/django/shell_context.py +23 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/tracking.py +8 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/utils.py +17 -6
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/sqlalchemy/core.py +9 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/otel.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/prometheus.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/sentry.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/retention.py +9 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/revert.py +14 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/schema.py +10 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/settings.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/timetravel.py +7 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/__init__.py +5 -3
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/manager.py +125 -26
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/spec.py +17 -8
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_trigger.sql +6 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/triggers.py +5 -6
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/utils.py +2 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/PKG-INFO +8 -1
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/SOURCES.txt +7 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/requires.txt +7 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/pyproject.toml +8 -1
- auditrum-0.3.2/tests/test_django_audit.py +180 -0
- auditrum-0.3.2/tests/test_django_init.py +60 -0
- auditrum-0.3.2/tests/test_django_partitions_command.py +70 -0
- auditrum-0.3.2/tests/test_django_shell_context.py +76 -0
- auditrum-0.3.2/tests/test_django_templatetags.py +54 -0
- auditrum-0.3.2/tests/test_django_utils.py +285 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_hash_chain.py +3 -1
- auditrum-0.3.2/tests/test_properties.py +233 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_tracking_manager.py +9 -2
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_triggers.py +6 -6
- auditrum-0.3.0/auditrum/integrations/django/shell_context.py +0 -3
- {auditrum-0.3.0 → auditrum-0.3.2}/LICENSE +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/commands/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/commands/auditrum_makemigrations.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/migrations/0001_initial.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/migrations/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/templates/audit/object_history.html +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/templatetags/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/templatetags/audit_tags.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/sqlalchemy/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/_template.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/__init__.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_attach_context.sql +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_current_user_id.sql +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_reconstruct_row.sql +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_reconstruct_table.sql +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/dependency_links.txt +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/entry_points.txt +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/top_level.txt +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/setup.cfg +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_blame.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_cli.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_context.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_executor.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_middleware.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_models.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_operations.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_runtime.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_settings.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_tracking_decorator.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_executor.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_hardening.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_historical_row.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_mixins.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_observability.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_retention.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_revert.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_schema.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_sqlalchemy_integration.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_timetravel.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_tracking_spec.py +0 -0
- {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auditrum
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Auditrum — a PostgreSQL audit system with rich contextual logging and Django integration
|
|
5
5
|
Author-email: Andrei Karbanovich <andrey.karbanovich@icloud.com>
|
|
6
6
|
License: MIT
|
|
@@ -39,8 +39,14 @@ Requires-Dist: prometheus-client>=0.17; extra == "observability"
|
|
|
39
39
|
Requires-Dist: sentry-sdk>=1.30; extra == "observability"
|
|
40
40
|
Provides-Extra: test
|
|
41
41
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
42
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
42
43
|
Requires-Dist: pytest-django>=4.5; extra == "test"
|
|
43
44
|
Requires-Dist: testcontainers[postgres]>=4.0; extra == "test"
|
|
45
|
+
Requires-Dist: hypothesis>=6.100; extra == "test"
|
|
46
|
+
Requires-Dist: pglast>=6.0; extra == "test"
|
|
47
|
+
Provides-Extra: typecheck
|
|
48
|
+
Requires-Dist: ty==0.0.31; extra == "typecheck"
|
|
49
|
+
Requires-Dist: django-stubs<6,>=5.0; extra == "typecheck"
|
|
44
50
|
Dynamic: license-file
|
|
45
51
|
|
|
46
52
|
<file name=0 path=README.md>#auditrum
|
|
@@ -331,6 +337,7 @@ directly on GitHub. Start with:
|
|
|
331
337
|
- [Architecture](docs/architecture.md) — schema, indexes, trigger flow, concurrency
|
|
332
338
|
|
|
333
339
|
Changelog: [CHANGELOG.md](CHANGELOG.md)
|
|
340
|
+
Roadmap to 1.0: [ROADMAP.md](ROADMAP.md)
|
|
334
341
|
|
|
335
342
|
---
|
|
336
343
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from functools import lru_cache
|
|
3
|
+
from typing import Literal, cast
|
|
3
4
|
|
|
4
5
|
import structlog
|
|
5
6
|
import typer
|
|
@@ -21,6 +22,8 @@ from auditrum.settings import PgAuditSettings
|
|
|
21
22
|
from auditrum.timetravel import reconstruct_row, reconstruct_table
|
|
22
23
|
from auditrum.triggers import generate_trigger_sql
|
|
23
24
|
|
|
25
|
+
__all__ = ["app"]
|
|
26
|
+
|
|
24
27
|
load_dotenv()
|
|
25
28
|
|
|
26
29
|
log = structlog.get_logger()
|
|
@@ -249,7 +252,12 @@ def run_static_sql(
|
|
|
249
252
|
|
|
250
253
|
try:
|
|
251
254
|
with connect(dsn=db_dsn) as conn, conn.cursor() as cur:
|
|
252
|
-
|
|
255
|
+
# Trust boundary: `sql` is produced by auditrum's own
|
|
256
|
+
# generators (schema/triggers/hardening/...), each of which
|
|
257
|
+
# pipes every identifier through `validate_identifier` before
|
|
258
|
+
# it reaches an f-string. pglast round-trip fuzzing planned
|
|
259
|
+
# in ROADMAP 0.4 is the stronger long-term guard.
|
|
260
|
+
cur.execute(sql) # ty: ignore[invalid-argument-type]
|
|
253
261
|
conn.commit()
|
|
254
262
|
log.info("SQL successfully executed")
|
|
255
263
|
except Exception as e:
|
|
@@ -275,7 +283,9 @@ def execute_or_print_sql(
|
|
|
275
283
|
return
|
|
276
284
|
|
|
277
285
|
with conn.cursor() as cur:
|
|
278
|
-
|
|
286
|
+
# Same trust boundary as run_static_sql: `sql_fn` is one
|
|
287
|
+
# of auditrum's validated generators.
|
|
288
|
+
cur.execute(sql) # ty: ignore[invalid-argument-type]
|
|
279
289
|
conn.commit()
|
|
280
290
|
log.info("SQL successfully executed")
|
|
281
291
|
except Exception as e:
|
|
@@ -455,6 +465,7 @@ def blame(
|
|
|
455
465
|
if fmt not in ("rich", "text", "json"):
|
|
456
466
|
log.error("format must be one of rich/text/json", got=fmt)
|
|
457
467
|
return
|
|
468
|
+
fmt_choice = cast(Literal["rich", "text", "json"], fmt)
|
|
458
469
|
|
|
459
470
|
try:
|
|
460
471
|
with connect(dsn=db_dsn) as conn:
|
|
@@ -473,7 +484,7 @@ def blame(
|
|
|
473
484
|
output = format_blame(
|
|
474
485
|
entries,
|
|
475
486
|
field=field,
|
|
476
|
-
fmt=
|
|
487
|
+
fmt=fmt_choice,
|
|
477
488
|
table=table,
|
|
478
489
|
object_id=object_id,
|
|
479
490
|
)
|
|
@@ -7,6 +7,13 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from auditrum.executor import ConnectionExecutor, NullExecutor
|
|
9
9
|
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AuditContext",
|
|
12
|
+
"audit_context",
|
|
13
|
+
"with_change_reason",
|
|
14
|
+
"with_context",
|
|
15
|
+
]
|
|
16
|
+
|
|
10
17
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
11
18
|
|
|
12
19
|
|
|
@@ -19,6 +19,13 @@ from collections.abc import Iterator
|
|
|
19
19
|
from contextlib import contextmanager
|
|
20
20
|
from typing import Any, Protocol, runtime_checkable
|
|
21
21
|
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ConnectionExecutor",
|
|
24
|
+
"CursorProtocol",
|
|
25
|
+
"NullExecutor",
|
|
26
|
+
"PsycopgExecutor",
|
|
27
|
+
]
|
|
28
|
+
|
|
22
29
|
|
|
23
30
|
class CursorProtocol(Protocol):
|
|
24
31
|
def execute(self, query: Any, params: Any = ...) -> Any: ...
|
|
@@ -30,6 +30,11 @@ See :doc:`../../docs/hardening.md` for the full deployment guide.
|
|
|
30
30
|
|
|
31
31
|
from auditrum.tracking.spec import validate_identifier
|
|
32
32
|
|
|
33
|
+
__all__ = [
|
|
34
|
+
"generate_grant_admin_sql",
|
|
35
|
+
"generate_revoke_sql",
|
|
36
|
+
]
|
|
37
|
+
|
|
33
38
|
|
|
34
39
|
def generate_revoke_sql(
|
|
35
40
|
table_name: str = "auditlog",
|
|
@@ -78,9 +83,14 @@ def generate_grant_admin_sql(
|
|
|
78
83
|
retention jobs, while the regular app role can only append new
|
|
79
84
|
entries through the trigger functions.
|
|
80
85
|
|
|
81
|
-
The granted privileges include ``INSERT`` so the admin role
|
|
82
|
-
owner of the trigger functions (which run ``SECURITY DEFINER``),
|
|
86
|
+
The granted privileges include ``INSERT`` so the admin role can be
|
|
87
|
+
the owner of the trigger functions (which run ``SECURITY DEFINER``),
|
|
83
88
|
and ``UPDATE, DELETE, TRUNCATE`` for maintenance.
|
|
89
|
+
|
|
90
|
+
Also grants ``USAGE`` on ``<table>_id_seq`` so the admin role can
|
|
91
|
+
actually INSERT — direct INSERTs into ``auditlog`` consume the
|
|
92
|
+
serial-PK sequence, and Postgres requires ``USAGE`` on it
|
|
93
|
+
independently of the table-level grant.
|
|
84
94
|
"""
|
|
85
95
|
validate_identifier(table_name, "table_name")
|
|
86
96
|
validate_identifier(context_table, "context_table")
|
|
@@ -91,5 +101,6 @@ def generate_grant_admin_sql(
|
|
|
91
101
|
f"ON {table_name} TO {admin_role};",
|
|
92
102
|
f"GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER "
|
|
93
103
|
f"ON {context_table} TO {admin_role};",
|
|
104
|
+
f"GRANT USAGE, SELECT ON SEQUENCE {table_name}_id_seq TO {admin_role};",
|
|
94
105
|
]
|
|
95
106
|
)
|
|
@@ -55,6 +55,12 @@ Caveats:
|
|
|
55
55
|
|
|
56
56
|
from auditrum.tracking.spec import validate_identifier
|
|
57
57
|
|
|
58
|
+
__all__ = [
|
|
59
|
+
"generate_hash_chain_sql",
|
|
60
|
+
"get_chain_tip",
|
|
61
|
+
"verify_chain",
|
|
62
|
+
]
|
|
63
|
+
|
|
58
64
|
# The shared SQL fragment that produces the canonical bytes hashed by both
|
|
59
65
|
# the insert trigger and the server-side verify query. Keeping this in one
|
|
60
66
|
# constant guarantees that the two SQL paths cannot drift.
|
|
@@ -133,6 +139,10 @@ BEGIN
|
|
|
133
139
|
-- legacy rows that pre-date the chain_seq column (chain_seq IS NULL,
|
|
134
140
|
-- ordered by id) so chain continuity is preserved across the
|
|
135
141
|
-- migration boundary.
|
|
142
|
+
--
|
|
143
|
+
-- Ordering picks the *most recent* row to chain to:
|
|
144
|
+
-- chain_seq DESC (newest first) with NULLs sorted last, then id
|
|
145
|
+
-- DESC as the tiebreaker for legacy rows. LIMIT 1 takes the head.
|
|
136
146
|
SELECT row_hash INTO last_hash
|
|
137
147
|
FROM {table_name}
|
|
138
148
|
WHERE row_hash IS NOT NULL
|
|
@@ -140,7 +150,7 @@ BEGIN
|
|
|
140
150
|
(chain_seq IS NOT NULL AND chain_seq < NEW.chain_seq)
|
|
141
151
|
OR chain_seq IS NULL
|
|
142
152
|
)
|
|
143
|
-
ORDER BY chain_seq NULLS
|
|
153
|
+
ORDER BY chain_seq DESC NULLS LAST, id DESC
|
|
144
154
|
LIMIT 1;
|
|
145
155
|
|
|
146
156
|
NEW.prev_hash := last_hash;
|
|
@@ -3,6 +3,11 @@ from django.utils.html import format_html
|
|
|
3
3
|
|
|
4
4
|
from auditrum.integrations.django.models import AuditContext, AuditLog
|
|
5
5
|
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AuditContextAdmin",
|
|
8
|
+
"AuditLogAdmin",
|
|
9
|
+
]
|
|
10
|
+
|
|
6
11
|
|
|
7
12
|
@admin.register(AuditContext)
|
|
8
13
|
class AuditContextAdmin(admin.ModelAdmin):
|
|
@@ -11,11 +16,10 @@ class AuditContextAdmin(admin.ModelAdmin):
|
|
|
11
16
|
ordering = ("-created_at",)
|
|
12
17
|
search_fields = ("id",)
|
|
13
18
|
|
|
19
|
+
@admin.display(description="Events")
|
|
14
20
|
def event_count(self, obj):
|
|
15
21
|
return obj.events.count()
|
|
16
22
|
|
|
17
|
-
event_count.short_description = "Events"
|
|
18
|
-
|
|
19
23
|
|
|
20
24
|
@admin.register(AuditLog)
|
|
21
25
|
class AuditLogAdmin(admin.ModelAdmin):
|
|
@@ -33,6 +37,7 @@ class AuditLogAdmin(admin.ModelAdmin):
|
|
|
33
37
|
readonly_fields = [f.name for f in AuditLog._meta.fields]
|
|
34
38
|
ordering = ("-changed_at",)
|
|
35
39
|
|
|
40
|
+
@admin.display(description="Linked Object")
|
|
36
41
|
def linked_object(self, obj):
|
|
37
42
|
try:
|
|
38
43
|
target = obj.content_object
|
|
@@ -47,5 +52,3 @@ class AuditLogAdmin(admin.ModelAdmin):
|
|
|
47
52
|
except Exception:
|
|
48
53
|
return str(target)
|
|
49
54
|
return str(target)
|
|
50
|
-
|
|
51
|
-
linked_object.short_description = "Linked Object"
|
|
@@ -5,6 +5,8 @@ from django.apps import AppConfig, apps
|
|
|
5
5
|
from auditrum.context import audit_context
|
|
6
6
|
from auditrum.integrations.django.executor import DjangoExecutor
|
|
7
7
|
|
|
8
|
+
__all__ = ["PgAuditIntegrationConfig"]
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
class PgAuditIntegrationConfig(AppConfig):
|
|
10
12
|
default_auto_field = "django.db.models.BigAutoField"
|
|
@@ -22,6 +22,11 @@ from auditrum.integrations.django.tracking import (
|
|
|
22
22
|
register as _imperative_register,
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
+
__all__ = [
|
|
26
|
+
"register",
|
|
27
|
+
"registry",
|
|
28
|
+
]
|
|
29
|
+
|
|
25
30
|
|
|
26
31
|
def register(model_cls: type[Model], **kwargs: Any) -> None:
|
|
27
32
|
"""Legacy imperative register. See ``integrations.django.tracking.register``."""
|
|
@@ -35,5 +35,8 @@ class Command(BaseCommand):
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
with psycopg.connect(db_dsn) as conn, conn.cursor() as cur:
|
|
38
|
-
|
|
38
|
+
# `sql` comes from generate_auditlog_partitions_sql, whose
|
|
39
|
+
# table name is validated via validate_identifier. See
|
|
40
|
+
# auditrum/schema.py:151 for the trust boundary.
|
|
41
|
+
cur.execute(sql) # ty: ignore[invalid-argument-type]
|
|
39
42
|
self.stdout.write(self.style.SUCCESS("Partitions created successfully."))
|
|
@@ -8,6 +8,11 @@ from django.conf import settings as django_settings
|
|
|
8
8
|
from auditrum.integrations.django.runtime import auditrum_context
|
|
9
9
|
from auditrum.integrations.django.settings import audit_settings
|
|
10
10
|
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuditrumMiddleware",
|
|
13
|
+
"RequestIDMiddleware",
|
|
14
|
+
]
|
|
15
|
+
|
|
11
16
|
|
|
12
17
|
def _hash_session_key(session_key: str | None) -> str | None:
|
|
13
18
|
"""One-way HMAC of the session key keyed by the project ``SECRET_KEY``.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from collections.abc import Iterator
|
|
2
2
|
from datetime import datetime
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
3
4
|
|
|
4
5
|
from django.contrib.contenttypes.models import ContentType
|
|
5
6
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
|
@@ -15,8 +16,30 @@ from auditrum.timetravel import (
|
|
|
15
16
|
reconstruct_table,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
# The mixins below are designed to be combined with Django classes at the
|
|
20
|
+
# call-site (AuditedModelMixin with models.Model, AuditHistoryMixin with
|
|
21
|
+
# admin.ModelAdmin). We swap the base under TYPE_CHECKING so a type checker
|
|
22
|
+
# sees the attributes those parent classes provide (_meta, pk, admin_site,
|
|
23
|
+
# model, get_object, get_urls...) while at runtime the mixin stays a plain
|
|
24
|
+
# object base — so `class Foo(MyMixin, models.Model)` still gives models.Model
|
|
25
|
+
# metaclass precedence, not a MRO conflict.
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from django.contrib.admin import ModelAdmin
|
|
28
|
+
from django.db.models import Model
|
|
29
|
+
|
|
30
|
+
_ModelBase = Model
|
|
31
|
+
_ModelAdminBase = ModelAdmin
|
|
32
|
+
else:
|
|
33
|
+
_ModelBase = object
|
|
34
|
+
_ModelAdminBase = object
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AuditHistoryMixin",
|
|
38
|
+
"AuditedModelMixin",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuditedModelMixin(_ModelBase):
|
|
20
43
|
"""Model mixin exposing per-instance audit history, time-travel, and field timelines.
|
|
21
44
|
|
|
22
45
|
All methods are read-only helpers built on top of the framework-agnostic
|
|
@@ -107,7 +130,7 @@ class AuditedModelMixin:
|
|
|
107
130
|
)
|
|
108
131
|
|
|
109
132
|
|
|
110
|
-
class AuditHistoryMixin:
|
|
133
|
+
class AuditHistoryMixin(_ModelAdminBase):
|
|
111
134
|
def get_urls(self):
|
|
112
135
|
urls = super().get_urls()
|
|
113
136
|
custom_urls = [
|
|
@@ -11,6 +11,13 @@ if TYPE_CHECKING:
|
|
|
11
11
|
from django.contrib.auth.models import AbstractBaseUser
|
|
12
12
|
from django.db.models import Model
|
|
13
13
|
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AuditContext",
|
|
16
|
+
"AuditLog",
|
|
17
|
+
"AuditLogManager",
|
|
18
|
+
"AuditLogQuerySet",
|
|
19
|
+
]
|
|
20
|
+
|
|
14
21
|
|
|
15
22
|
class AuditLogQuerySet(models.QuerySet):
|
|
16
23
|
"""QuerySet with single-table-aware helpers for audit history lookups.
|
|
@@ -57,7 +64,12 @@ class AuditLogQuerySet(models.QuerySet):
|
|
|
57
64
|
return self.order_by("-changed_at")[:limit]
|
|
58
65
|
|
|
59
66
|
|
|
60
|
-
class AuditLogManager(models.Manager.from_queryset(AuditLogQuerySet)): #
|
|
67
|
+
class AuditLogManager(models.Manager.from_queryset(AuditLogQuerySet)): # ty: ignore[unsupported-base]
|
|
68
|
+
# Manager.from_queryset() returns a dynamically-built subclass whose
|
|
69
|
+
# identity type checkers can't statically infer. The pattern is the
|
|
70
|
+
# standard Django idiom for copying queryset methods onto a manager,
|
|
71
|
+
# so we accept the base-class unknown here rather than duplicate
|
|
72
|
+
# every QuerySet method on the manager by hand.
|
|
61
73
|
"""Default manager for :class:`AuditLog` exposing the queryset helpers."""
|
|
62
74
|
|
|
63
75
|
def get_queryset(self) -> AuditLogQuerySet:
|
|
@@ -20,6 +20,11 @@ from django.db.migrations.operations.base import Operation
|
|
|
20
20
|
|
|
21
21
|
from auditrum.tracking import FieldFilter, TrackSpec, TriggerManager
|
|
22
22
|
|
|
23
|
+
__all__ = [
|
|
24
|
+
"InstallTrigger",
|
|
25
|
+
"UninstallTrigger",
|
|
26
|
+
]
|
|
27
|
+
|
|
23
28
|
|
|
24
29
|
def _spec_to_deconstruct_kwargs(spec: TrackSpec) -> dict[str, Any]:
|
|
25
30
|
kwargs: dict[str, Any] = {"table": spec.table}
|
|
@@ -2,6 +2,11 @@ import re
|
|
|
2
2
|
|
|
3
3
|
from django.conf import settings
|
|
4
4
|
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AuditSettings",
|
|
7
|
+
"audit_settings",
|
|
8
|
+
]
|
|
9
|
+
|
|
5
10
|
# Postgres custom GUCs are required to have the form `<class>.<name>` where
|
|
6
11
|
# both halves are simple identifiers. We validate user-supplied GUC names
|
|
7
12
|
# against this regex before they are interpolated into PL/pgSQL function
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import contextlib
|
|
3
|
+
|
|
4
|
+
from .utils import audit_tracked
|
|
5
|
+
|
|
6
|
+
# Bind the context manager to a module-level name so CPython's
|
|
7
|
+
# refcount-driven GC doesn't immediately reclaim it — when the
|
|
8
|
+
# manager goes out of scope its generator is closed, which triggers
|
|
9
|
+
# __exit__, which pops the audit context and resets "source". Holding
|
|
10
|
+
# a reference here keeps the shell session's source="shell" stamp on
|
|
11
|
+
# every audit row written from the REPL.
|
|
12
|
+
_shell_ctx = audit_tracked(source="shell")
|
|
13
|
+
_shell_ctx.__enter__()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@atexit.register
|
|
17
|
+
def _release_shell_ctx() -> None:
|
|
18
|
+
# Run cleanup before interpreter shutdown so the generator's
|
|
19
|
+
# finally block runs while its transitive imports (the executor
|
|
20
|
+
# chain in auditrum.context) are still importable. Swallow any
|
|
21
|
+
# late shutdown races — the process is going away either way.
|
|
22
|
+
with contextlib.suppress(Exception):
|
|
23
|
+
_shell_ctx.__exit__(None, None, None)
|
|
@@ -25,6 +25,14 @@ from typing import TYPE_CHECKING, Any, TypeVar
|
|
|
25
25
|
|
|
26
26
|
from auditrum.tracking import FieldFilter, TrackSpec
|
|
27
27
|
|
|
28
|
+
__all__ = [
|
|
29
|
+
"clear_registry",
|
|
30
|
+
"get_registered_specs",
|
|
31
|
+
"register",
|
|
32
|
+
"specs_by_app_label",
|
|
33
|
+
"track",
|
|
34
|
+
]
|
|
35
|
+
|
|
28
36
|
if TYPE_CHECKING:
|
|
29
37
|
from django.db.models import Model
|
|
30
38
|
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
1
4
|
from django.contrib.admin.templatetags.admin_urls import admin_urlname
|
|
2
5
|
from django.db import models
|
|
3
6
|
from django.shortcuts import resolve_url
|
|
4
|
-
from django.utils import timezone
|
|
5
7
|
from django.utils.formats import date_format
|
|
6
8
|
from django.utils.html import format_html
|
|
7
9
|
from django.utils.safestring import mark_safe
|
|
8
10
|
|
|
9
11
|
from auditrum.utils import audit_tracked # re-exported for backwards compatibility
|
|
10
12
|
|
|
13
|
+
|
|
14
|
+
class _ExecuteCursor(Protocol):
|
|
15
|
+
def execute(self, query: str, params: Any = ..., /) -> Any: ...
|
|
16
|
+
|
|
11
17
|
__all__ = [
|
|
12
18
|
"audit_tracked",
|
|
13
19
|
"link",
|
|
@@ -23,8 +29,8 @@ def link(href: str, text: str) -> str:
|
|
|
23
29
|
return format_html('<a href="{}">{}</a>', href, text)
|
|
24
30
|
|
|
25
31
|
|
|
26
|
-
def link_to_related_object(obj: models.Model, name: str = None) -> str:
|
|
27
|
-
url = resolve_url(admin_urlname(obj._meta, "change"), obj.pk)
|
|
32
|
+
def link_to_related_object(obj: models.Model, name: str | None = None) -> str:
|
|
33
|
+
url = resolve_url(admin_urlname(obj._meta, "change"), obj.pk) # ty: ignore[invalid-argument-type]
|
|
28
34
|
return link(url, name or str(obj))
|
|
29
35
|
|
|
30
36
|
|
|
@@ -49,7 +55,7 @@ def resolve_field_value(model_class, field_name, value):
|
|
|
49
55
|
try:
|
|
50
56
|
parsed = value
|
|
51
57
|
if isinstance(value, str):
|
|
52
|
-
parsed =
|
|
58
|
+
parsed = datetime.fromisoformat(value)
|
|
53
59
|
if isinstance(field, models.DateTimeField):
|
|
54
60
|
value = date_format(parsed, format="DATETIME_FORMAT")
|
|
55
61
|
elif isinstance(field, models.DateField):
|
|
@@ -124,6 +130,11 @@ def render_log_changes(log):
|
|
|
124
130
|
return mark_safe("<em class='text-gray-500'>No changes</em>")
|
|
125
131
|
|
|
126
132
|
|
|
127
|
-
def set_var(cursor, key: str, value) -> None:
|
|
133
|
+
def set_var(cursor: _ExecuteCursor, key: str, value: Any) -> None:
|
|
134
|
+
# set_config(name, value, is_local=false) is the parameterised form
|
|
135
|
+
# of SET — both the GUC name and value go through psycopg's binding
|
|
136
|
+
# layer, so an attacker-controlled `key` can't break out into DDL.
|
|
137
|
+
# The f-string version that used to live here was a real injection
|
|
138
|
+
# surface any time `key` wasn't a compile-time constant.
|
|
128
139
|
if value is not None:
|
|
129
|
-
cursor.execute(
|
|
140
|
+
cursor.execute("SELECT set_config(%s, %s, false)", [key, str(value)])
|
|
@@ -17,6 +17,15 @@ from auditrum.schema import (
|
|
|
17
17
|
)
|
|
18
18
|
from auditrum.tracking import FieldFilter, SyncReport, TrackSpec, TriggerManager
|
|
19
19
|
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SQLAlchemyExecutor",
|
|
22
|
+
"bootstrap_schema",
|
|
23
|
+
"clear_registry",
|
|
24
|
+
"registered_specs",
|
|
25
|
+
"sync",
|
|
26
|
+
"track_table",
|
|
27
|
+
]
|
|
28
|
+
|
|
20
29
|
if TYPE_CHECKING:
|
|
21
30
|
from sqlalchemy import Table
|
|
22
31
|
from sqlalchemy.engine import Connection, Engine
|
|
@@ -25,6 +25,11 @@ from psycopg import sql
|
|
|
25
25
|
|
|
26
26
|
from auditrum.tracking.spec import validate_identifier
|
|
27
27
|
|
|
28
|
+
__all__ = [
|
|
29
|
+
"drop_old_partitions",
|
|
30
|
+
"generate_purge_sql",
|
|
31
|
+
]
|
|
32
|
+
|
|
28
33
|
_INTERVAL_RE = re.compile(
|
|
29
34
|
r"^\s*(\d+)\s*(day|days|week|weeks|month|months|year|years)\s*$", re.IGNORECASE
|
|
30
35
|
)
|
|
@@ -54,7 +59,10 @@ def _parse_interval(expr: str) -> relativedelta:
|
|
|
54
59
|
return relativedelta(months=n)
|
|
55
60
|
if unit.startswith("year"):
|
|
56
61
|
return relativedelta(years=n)
|
|
57
|
-
raise ValueError(
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Unsupported retention interval unit: {unit!r}. Supported units are "
|
|
64
|
+
f"'day'/'days', 'week'/'weeks', 'month'/'months', 'year'/'years'."
|
|
65
|
+
)
|
|
58
66
|
|
|
59
67
|
|
|
60
68
|
def _cutoff_for(expr: str) -> datetime:
|
|
@@ -2,8 +2,21 @@ from psycopg import sql
|
|
|
2
2
|
|
|
3
3
|
from auditrum.tracking.spec import validate_identifier
|
|
4
4
|
|
|
5
|
+
__all__ = [
|
|
6
|
+
"generate_revert_sql",
|
|
7
|
+
"generate_revert_sql_from_log",
|
|
8
|
+
"get_revert_columns_from_log",
|
|
9
|
+
]
|
|
10
|
+
|
|
5
11
|
|
|
6
12
|
def get_revert_columns_from_log(conn, audit_table: str, log_id: int) -> list[str]:
|
|
13
|
+
"""Return the column names that should be restored when reverting a log entry.
|
|
14
|
+
|
|
15
|
+
Skips ``id`` because the primary key is invariant — the revert is
|
|
16
|
+
keyed *by* it, restoring it would be a no-op at best and a type
|
|
17
|
+
mismatch at worst (jsonb ``->>`` always returns ``text``, but most
|
|
18
|
+
primary keys are ``integer`` or ``bigint``).
|
|
19
|
+
"""
|
|
7
20
|
validate_identifier(audit_table, "audit_table")
|
|
8
21
|
query = sql.SQL("SELECT jsonb_object_keys(old_data) FROM {} WHERE id = %s").format(
|
|
9
22
|
sql.Identifier(audit_table)
|
|
@@ -11,7 +24,7 @@ def get_revert_columns_from_log(conn, audit_table: str, log_id: int) -> list[str
|
|
|
11
24
|
with conn.cursor() as cur:
|
|
12
25
|
cur.execute(query, (int(log_id),))
|
|
13
26
|
keys = cur.fetchall()
|
|
14
|
-
return [row[0] for row in keys]
|
|
27
|
+
return [row[0] for row in keys if row[0] != "id"]
|
|
15
28
|
|
|
16
29
|
|
|
17
30
|
def generate_revert_sql(
|
|
@@ -4,6 +4,16 @@ from dateutil.relativedelta import relativedelta
|
|
|
4
4
|
|
|
5
5
|
from auditrum.tracking.spec import validate_identifier
|
|
6
6
|
|
|
7
|
+
__all__ = [
|
|
8
|
+
"generate_audit_attach_context_sql",
|
|
9
|
+
"generate_audit_context_table_sql",
|
|
10
|
+
"generate_audit_current_user_id_sql",
|
|
11
|
+
"generate_audit_reconstruct_sql",
|
|
12
|
+
"generate_auditlog_partitions_sql",
|
|
13
|
+
"generate_auditlog_table_sql",
|
|
14
|
+
"generate_jsonb_diff_function_sql",
|
|
15
|
+
]
|
|
16
|
+
|
|
7
17
|
|
|
8
18
|
def generate_audit_context_table_sql(context_table: str = "audit_context") -> str:
|
|
9
19
|
"""Generate SQL for the context table that groups events by request/job/command.
|