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.
Files changed (98) hide show
  1. {auditrum-0.3.0 → auditrum-0.3.2}/PKG-INFO +8 -1
  2. {auditrum-0.3.0 → auditrum-0.3.2}/README.md +1 -0
  3. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/blame.py +6 -0
  4. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/cli.py +14 -3
  5. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/context.py +7 -0
  6. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/executor.py +7 -0
  7. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/hardening.py +13 -2
  8. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/hash_chain.py +11 -1
  9. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/__init__.py +7 -0
  10. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/admin.py +7 -4
  11. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/apps.py +2 -0
  12. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/audit.py +5 -0
  13. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/executor.py +2 -0
  14. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/commands/audit_add_partitions.py +4 -1
  15. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/middleware.py +5 -0
  16. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/mixins.py +26 -3
  17. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/models.py +13 -1
  18. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/operations.py +5 -0
  19. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/runtime.py +5 -0
  20. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/settings.py +5 -0
  21. auditrum-0.3.2/auditrum/integrations/django/shell_context.py +23 -0
  22. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/tracking.py +8 -0
  23. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/utils.py +17 -6
  24. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/sqlalchemy/core.py +9 -0
  25. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/otel.py +2 -0
  26. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/prometheus.py +2 -0
  27. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/sentry.py +2 -0
  28. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/retention.py +9 -1
  29. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/revert.py +14 -1
  30. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/schema.py +10 -0
  31. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/settings.py +2 -0
  32. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/timetravel.py +7 -0
  33. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/__init__.py +5 -3
  34. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/manager.py +125 -26
  35. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/spec.py +17 -8
  36. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_trigger.sql +6 -1
  37. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/triggers.py +5 -6
  38. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/utils.py +2 -0
  39. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/PKG-INFO +8 -1
  40. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/SOURCES.txt +7 -0
  41. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/requires.txt +7 -0
  42. {auditrum-0.3.0 → auditrum-0.3.2}/pyproject.toml +8 -1
  43. auditrum-0.3.2/tests/test_django_audit.py +180 -0
  44. auditrum-0.3.2/tests/test_django_init.py +60 -0
  45. auditrum-0.3.2/tests/test_django_partitions_command.py +70 -0
  46. auditrum-0.3.2/tests/test_django_shell_context.py +76 -0
  47. auditrum-0.3.2/tests/test_django_templatetags.py +54 -0
  48. auditrum-0.3.2/tests/test_django_utils.py +285 -0
  49. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_hash_chain.py +3 -1
  50. auditrum-0.3.2/tests/test_properties.py +233 -0
  51. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_tracking_manager.py +9 -2
  52. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_triggers.py +6 -6
  53. auditrum-0.3.0/auditrum/integrations/django/shell_context.py +0 -3
  54. {auditrum-0.3.0 → auditrum-0.3.2}/LICENSE +0 -0
  55. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/__init__.py +0 -0
  56. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/__init__.py +0 -0
  57. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/__init__.py +0 -0
  58. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/commands/__init__.py +0 -0
  59. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/management/commands/auditrum_makemigrations.py +0 -0
  60. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/migrations/0001_initial.py +0 -0
  61. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/migrations/__init__.py +0 -0
  62. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/templates/audit/object_history.html +0 -0
  63. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/templatetags/__init__.py +0 -0
  64. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/django/templatetags/audit_tags.py +0 -0
  65. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/integrations/sqlalchemy/__init__.py +0 -0
  66. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/observability/__init__.py +0 -0
  67. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/_template.py +0 -0
  68. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/__init__.py +0 -0
  69. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_attach_context.sql +0 -0
  70. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_current_user_id.sql +0 -0
  71. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_reconstruct_row.sql +0 -0
  72. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum/tracking/templates/audit_reconstruct_table.sql +0 -0
  73. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/dependency_links.txt +0 -0
  74. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/entry_points.txt +0 -0
  75. {auditrum-0.3.0 → auditrum-0.3.2}/auditrum.egg-info/top_level.txt +0 -0
  76. {auditrum-0.3.0 → auditrum-0.3.2}/setup.cfg +0 -0
  77. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_blame.py +0 -0
  78. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_cli.py +0 -0
  79. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_context.py +0 -0
  80. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_executor.py +0 -0
  81. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_middleware.py +0 -0
  82. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_models.py +0 -0
  83. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_operations.py +0 -0
  84. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_runtime.py +0 -0
  85. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_settings.py +0 -0
  86. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_django_tracking_decorator.py +0 -0
  87. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_executor.py +0 -0
  88. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_hardening.py +0 -0
  89. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_historical_row.py +0 -0
  90. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_mixins.py +0 -0
  91. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_observability.py +0 -0
  92. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_retention.py +0 -0
  93. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_revert.py +0 -0
  94. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_schema.py +0 -0
  95. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_sqlalchemy_integration.py +0 -0
  96. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_timetravel.py +0 -0
  97. {auditrum-0.3.0 → auditrum-0.3.2}/tests/test_tracking_spec.py +0 -0
  98. {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.0
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
 
@@ -286,6 +286,7 @@ directly on GitHub. Start with:
286
286
  - [Architecture](docs/architecture.md) — schema, indexes, trigger flow, concurrency
287
287
 
288
288
  Changelog: [CHANGELOG.md](CHANGELOG.md)
289
+ Roadmap to 1.0: [ROADMAP.md](ROADMAP.md)
289
290
 
290
291
  ---
291
292
 
@@ -26,6 +26,12 @@ from psycopg import sql as pg_sql
26
26
 
27
27
  from auditrum.tracking.spec import validate_identifier
28
28
 
29
+ __all__ = [
30
+ "BlameEntry",
31
+ "fetch_blame",
32
+ "format_blame",
33
+ ]
34
+
29
35
 
30
36
  @dataclass(frozen=True)
31
37
  class BlameEntry:
@@ -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
- cur.execute(sql)
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
- cur.execute(sql)
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=fmt, # type: ignore[arg-type]
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 is the
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 FIRST, id DESC
153
+ ORDER BY chain_seq DESC NULLS LAST, id DESC
144
154
  LIMIT 1;
145
155
 
146
156
  NEW.prev_hash := last_hash;
@@ -12,6 +12,13 @@ specific conveniences so apps can write::
12
12
  ...
13
13
  """
14
14
 
15
+ __all__ = [
16
+ "AuditContext",
17
+ "AuditLog",
18
+ "register",
19
+ "track",
20
+ ]
21
+
15
22
  default_app_config = "auditrum.integrations.django.apps.PgAuditIntegrationConfig"
16
23
 
17
24
 
@@ -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``."""
@@ -2,6 +2,8 @@ from collections.abc import Iterator
2
2
  from contextlib import contextmanager
3
3
  from typing import Any
4
4
 
5
+ __all__ = ["DjangoExecutor"]
6
+
5
7
 
6
8
  class DjangoExecutor:
7
9
  """ConnectionExecutor backed by a Django database wrapper.
@@ -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
- cur.execute(sql)
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
- class AuditedModelMixin:
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)): # type: ignore[misc]
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}
@@ -43,6 +43,11 @@ from django.db import connection
43
43
 
44
44
  from auditrum.integrations.django.settings import audit_settings
45
45
 
46
+ __all__ = [
47
+ "auditrum_context",
48
+ "current_context",
49
+ ]
50
+
46
51
 
47
52
  @dataclass(frozen=True)
48
53
  class _Context:
@@ -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 = timezone.datetime.fromisoformat(value)
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(f"SET {key} = %s", [str(value)])
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
@@ -16,6 +16,8 @@ from __future__ import annotations
16
16
 
17
17
  from typing import Any
18
18
 
19
+ __all__ = ["enrich_metadata"]
20
+
19
21
 
20
22
  def enrich_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
21
23
  """Merge OTel trace identifiers into ``metadata`` if possible.
@@ -34,6 +34,8 @@ from psycopg import sql as pg_sql
34
34
 
35
35
  from auditrum.tracking.spec import validate_identifier
36
36
 
37
+ __all__ = ["AuditrumCollector"]
38
+
37
39
 
38
40
  class AuditrumCollector:
39
41
  """prometheus_client collector exposing audit event counts.
@@ -11,6 +11,8 @@ from __future__ import annotations
11
11
  import contextlib
12
12
  from typing import Any
13
13
 
14
+ __all__ = ["add_breadcrumb_for_context"]
15
+
14
16
 
15
17
  def add_breadcrumb_for_context(
16
18
  metadata: dict[str, Any], *, category: str = "auditrum"
@@ -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(f"Unsupported unit: {unit}")
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.
@@ -2,6 +2,8 @@
2
2
  from pydantic import Field
3
3
  from pydantic_settings import BaseSettings, SettingsConfigDict
4
4
 
5
+ __all__ = ["PgAuditSettings"]
6
+
5
7
 
6
8
  class PgAuditSettings(BaseSettings):
7
9
  model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")