auditrum 0.2.0__py3-none-any.whl
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/__init__.py +0 -0
- auditrum/cli.py +237 -0
- auditrum/context.py +131 -0
- auditrum/integrations/__init__.py +0 -0
- auditrum/integrations/django/__init__.py +0 -0
- auditrum/integrations/django/admin.py +34 -0
- auditrum/integrations/django/apps.py +23 -0
- auditrum/integrations/django/audit.py +27 -0
- auditrum/integrations/django/hooks.py +29 -0
- auditrum/integrations/django/management/__init__.py +0 -0
- auditrum/integrations/django/management/commands/__init__.py +0 -0
- auditrum/integrations/django/management/commands/audit_add_partitions.py +40 -0
- auditrum/integrations/django/middleware.py +25 -0
- auditrum/integrations/django/migrations/0001_initial.py +24 -0
- auditrum/integrations/django/migrations/__init__.py +0 -0
- auditrum/integrations/django/mixins.py +46 -0
- auditrum/integrations/django/models.py +31 -0
- auditrum/integrations/django/settings.py +14 -0
- auditrum/integrations/django/shell_context.py +3 -0
- auditrum/integrations/django/templates/audit/object_history.html +58 -0
- auditrum/integrations/django/templatetags/__init__.py +0 -0
- auditrum/integrations/django/templatetags/audit_tags.py +14 -0
- auditrum/integrations/django/utils.py +172 -0
- auditrum/revert.py +39 -0
- auditrum/schema.py +79 -0
- auditrum/settings.py +20 -0
- auditrum/triggers.py +118 -0
- auditrum/utils.py +0 -0
- auditrum-0.2.0.dist-info/METADATA +266 -0
- auditrum-0.2.0.dist-info/RECORD +34 -0
- auditrum-0.2.0.dist-info/WHEEL +5 -0
- auditrum-0.2.0.dist-info/entry_points.txt +2 -0
- auditrum-0.2.0.dist-info/licenses/LICENSE +21 -0
- auditrum-0.2.0.dist-info/top_level.txt +1 -0
auditrum/__init__.py
ADDED
|
File without changes
|
auditrum/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from typing import Callable, Optional, Tuple
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
import typer
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from psycopg import connect
|
|
7
|
+
from rich import print as rich_print
|
|
8
|
+
from settings import PgAuditSettings
|
|
9
|
+
|
|
10
|
+
from auditrum.revert import generate_revert_sql_from_log
|
|
11
|
+
from auditrum.schema import (
|
|
12
|
+
generate_auditlog_partitions_sql,
|
|
13
|
+
generate_auditlog_table_sql,
|
|
14
|
+
)
|
|
15
|
+
from auditrum.triggers import generate_trigger_sql
|
|
16
|
+
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
# Helper function to get DB DSN from arguments or environment
|
|
20
|
+
def get_db_dsn(
|
|
21
|
+
dsn: Optional[str] = None,
|
|
22
|
+
host: Optional[str] = None,
|
|
23
|
+
port: Optional[int] = None,
|
|
24
|
+
user: Optional[str] = None,
|
|
25
|
+
password: Optional[str] = None,
|
|
26
|
+
dbname: Optional[str] = None,
|
|
27
|
+
) -> Optional[str]:
|
|
28
|
+
if dsn:
|
|
29
|
+
return dsn
|
|
30
|
+
if all([host, port, user, password, dbname]):
|
|
31
|
+
return f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
|
|
32
|
+
return settings.db_dsn
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = typer.Typer()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def main(
|
|
40
|
+
verbose: Optional[int] = typer.Option(
|
|
41
|
+
1, "--verbose", "-v", help="Verbosity level (0=quiet, 1=default, 2=debug)"
|
|
42
|
+
),
|
|
43
|
+
):
|
|
44
|
+
level = structlog.INFO
|
|
45
|
+
if verbose == 0:
|
|
46
|
+
level = structlog.NOTSET
|
|
47
|
+
elif verbose == 2:
|
|
48
|
+
level = structlog.DEBUG
|
|
49
|
+
|
|
50
|
+
structlog.configure(
|
|
51
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
52
|
+
processors=[
|
|
53
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
54
|
+
structlog.dev.ConsoleRenderer(),
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
log = structlog.get_logger()
|
|
60
|
+
|
|
61
|
+
settings = PgAuditSettings()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def generate_trigger(
|
|
66
|
+
table: str,
|
|
67
|
+
audit_table: str = settings.audit_table,
|
|
68
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
|
|
69
|
+
output: Optional[str] = typer.Option(
|
|
70
|
+
None, "--output", "-o", help="Write SQL to file instead of printing"
|
|
71
|
+
),
|
|
72
|
+
dsn: Optional[str] = typer.Option(None, "--db-dsn"),
|
|
73
|
+
host: Optional[str] = typer.Option(None, "--db-host"),
|
|
74
|
+
port: Optional[int] = typer.Option(None, "--db-port"),
|
|
75
|
+
user: Optional[str] = typer.Option(None, "--db-user"),
|
|
76
|
+
password: Optional[str] = typer.Option(None, "--db-password"),
|
|
77
|
+
dbname: Optional[str] = typer.Option(None, "--db-name"),
|
|
78
|
+
):
|
|
79
|
+
log.info("Generating trigger SQL")
|
|
80
|
+
sql = generate_trigger_sql(table_name=table, audit_table=audit_table)
|
|
81
|
+
db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
|
|
82
|
+
execute_or_print_sql(lambda conn: sql, (), dry_run, output, db_dsn=db_dsn)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def init_schema(
|
|
87
|
+
audit_table: str = settings.audit_table,
|
|
88
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
|
|
89
|
+
output: Optional[str] = typer.Option(
|
|
90
|
+
None, "--output", "-o", help="Write SQL to file instead of printing"
|
|
91
|
+
),
|
|
92
|
+
dsn: Optional[str] = typer.Option(None, "--db-dsn"),
|
|
93
|
+
host: Optional[str] = typer.Option(None, "--db-host"),
|
|
94
|
+
port: Optional[int] = typer.Option(None, "--db-port"),
|
|
95
|
+
user: Optional[str] = typer.Option(None, "--db-user"),
|
|
96
|
+
password: Optional[str] = typer.Option(None, "--db-password"),
|
|
97
|
+
dbname: Optional[str] = typer.Option(None, "--db-name"),
|
|
98
|
+
):
|
|
99
|
+
log.info("Generating auditlog schema SQL")
|
|
100
|
+
sql = generate_auditlog_table_sql(audit_table)
|
|
101
|
+
db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
|
|
102
|
+
execute_or_print_sql(lambda conn: sql, (), dry_run, output, db_dsn=db_dsn)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def create_partitions(
|
|
107
|
+
audit_table: str = settings.audit_table,
|
|
108
|
+
months: int = 3,
|
|
109
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
|
|
110
|
+
output: Optional[str] = typer.Option(
|
|
111
|
+
None, "--output", "-o", help="Write SQL to file instead of printing"
|
|
112
|
+
),
|
|
113
|
+
dsn: Optional[str] = typer.Option(None, "--db-dsn"),
|
|
114
|
+
host: Optional[str] = typer.Option(None, "--db-host"),
|
|
115
|
+
port: Optional[int] = typer.Option(None, "--db-port"),
|
|
116
|
+
user: Optional[str] = typer.Option(None, "--db-user"),
|
|
117
|
+
password: Optional[str] = typer.Option(None, "--db-password"),
|
|
118
|
+
dbname: Optional[str] = typer.Option(None, "--db-name"),
|
|
119
|
+
):
|
|
120
|
+
log.info("Generating partition SQL", months=months)
|
|
121
|
+
sql = generate_auditlog_partitions_sql(audit_table, months)
|
|
122
|
+
db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
|
|
123
|
+
execute_or_print_sql(lambda conn: sql, (), dry_run, output, db_dsn=db_dsn)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def revert(
|
|
128
|
+
table: str,
|
|
129
|
+
record_id: str,
|
|
130
|
+
log_id: int,
|
|
131
|
+
audit_table: str = settings.audit_table,
|
|
132
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
|
|
133
|
+
output: Optional[str] = typer.Option(
|
|
134
|
+
None, "--output", "-o", help="Write SQL to file instead of printing"
|
|
135
|
+
),
|
|
136
|
+
dsn: Optional[str] = typer.Option(None, "--db-dsn"),
|
|
137
|
+
host: Optional[str] = typer.Option(None, "--db-host"),
|
|
138
|
+
port: Optional[int] = typer.Option(None, "--db-port"),
|
|
139
|
+
user: Optional[str] = typer.Option(None, "--db-user"),
|
|
140
|
+
password: Optional[str] = typer.Option(None, "--db-password"),
|
|
141
|
+
dbname: Optional[str] = typer.Option(None, "--db-name"),
|
|
142
|
+
):
|
|
143
|
+
log.info("Generating revert SQL", table=table, record_id=record_id, log_id=log_id)
|
|
144
|
+
db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
|
|
145
|
+
execute_or_print_sql(
|
|
146
|
+
sql_fn=generate_revert_sql_from_log,
|
|
147
|
+
sql_args=(audit_table, table, record_id, log_id),
|
|
148
|
+
dry_run=dry_run,
|
|
149
|
+
output=output,
|
|
150
|
+
db_dsn=db_dsn,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def status(
|
|
156
|
+
audit_table: str = settings.audit_table,
|
|
157
|
+
dsn: Optional[str] = typer.Option(None, "--db-dsn"),
|
|
158
|
+
host: Optional[str] = typer.Option(None, "--db-host"),
|
|
159
|
+
port: Optional[int] = typer.Option(None, "--db-port"),
|
|
160
|
+
user: Optional[str] = typer.Option(None, "--db-user"),
|
|
161
|
+
password: Optional[str] = typer.Option(None, "--db-password"),
|
|
162
|
+
dbname: Optional[str] = typer.Option(None, "--db-name"),
|
|
163
|
+
):
|
|
164
|
+
log.info("Checking audit trigger and table status")
|
|
165
|
+
|
|
166
|
+
db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
with connect(dsn=db_dsn) as conn, conn.cursor() as cur:
|
|
170
|
+
# Check triggers
|
|
171
|
+
cur.execute("""
|
|
172
|
+
SELECT event_object_table AS "table", trigger_name
|
|
173
|
+
FROM information_schema.triggers
|
|
174
|
+
WHERE action_statement ILIKE '%audit_trigger_fn%'
|
|
175
|
+
""")
|
|
176
|
+
triggers = cur.fetchall()
|
|
177
|
+
|
|
178
|
+
# Check partitions
|
|
179
|
+
cur.execute(f"""
|
|
180
|
+
SELECT inhrelid::regclass AS partition
|
|
181
|
+
FROM pg_inherits
|
|
182
|
+
WHERE inhparent = '{audit_table}'::regclass
|
|
183
|
+
""")
|
|
184
|
+
partitions = cur.fetchall()
|
|
185
|
+
|
|
186
|
+
# Check auditlog size and row count
|
|
187
|
+
cur.execute(f"SELECT COUNT(*) FROM {audit_table}")
|
|
188
|
+
row_count = cur.fetchone()[0]
|
|
189
|
+
|
|
190
|
+
log.info("Audit triggers found", count=len(triggers))
|
|
191
|
+
for table, trig in triggers:
|
|
192
|
+
rich_print(f"[yellow]- {table}: {trig}[/yellow]")
|
|
193
|
+
|
|
194
|
+
log.info("Auditlog partitions", count=len(partitions))
|
|
195
|
+
for p in partitions:
|
|
196
|
+
rich_print(f"[blue]- {p[0]}[/blue]")
|
|
197
|
+
|
|
198
|
+
log.info("Auditlog row count", rows=row_count)
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
log.error("Failed to check audit status", error=str(e))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def display_sql(sql: str, output: Optional[str] = None):
|
|
205
|
+
if output:
|
|
206
|
+
with open(output, "w") as f:
|
|
207
|
+
f.write(sql + "\n")
|
|
208
|
+
log.info("SQL written to file", path=output)
|
|
209
|
+
else:
|
|
210
|
+
rich_print(f"[bold green]{sql}[/bold green]")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def execute_or_print_sql(
|
|
214
|
+
sql_fn: Callable[..., str],
|
|
215
|
+
sql_args: Tuple,
|
|
216
|
+
dry_run: bool,
|
|
217
|
+
output: Optional[str] = None,
|
|
218
|
+
db_dsn: Optional[str] = None,
|
|
219
|
+
):
|
|
220
|
+
try:
|
|
221
|
+
with connect(dsn=db_dsn) as conn:
|
|
222
|
+
sql = sql_fn(conn, *sql_args)
|
|
223
|
+
|
|
224
|
+
if dry_run:
|
|
225
|
+
display_sql(sql, output)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
with conn.cursor() as cur:
|
|
229
|
+
cur.execute(sql)
|
|
230
|
+
conn.commit()
|
|
231
|
+
log.info("SQL successfully executed")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
log.error("Failed to execute SQL", error=str(e))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
app()
|
auditrum/context.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Callable, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuditContext:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._data: ContextVar[Dict[str, Any]] = ContextVar("audit_ctx_data")
|
|
10
|
+
self._reason_stack: ContextVar[list[str]] = ContextVar("audit_ctx_reason")
|
|
11
|
+
|
|
12
|
+
def _ensure_data(self) -> dict:
|
|
13
|
+
try:
|
|
14
|
+
return self._data.get()
|
|
15
|
+
except LookupError:
|
|
16
|
+
value = {}
|
|
17
|
+
self._data.set(value)
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
def _ensure_reason_stack(self) -> list[str]:
|
|
21
|
+
try:
|
|
22
|
+
return self._reason_stack.get()
|
|
23
|
+
except LookupError:
|
|
24
|
+
value = []
|
|
25
|
+
self._reason_stack.set(value)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def set(self, key: str, value: Any):
|
|
29
|
+
ctx = self._ensure_data().copy()
|
|
30
|
+
ctx[key] = value
|
|
31
|
+
self._data.set(ctx)
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> Any:
|
|
34
|
+
return self._ensure_data().get(key)
|
|
35
|
+
|
|
36
|
+
def push_change_reason(self, reason: str):
|
|
37
|
+
stack = self._ensure_reason_stack().copy()
|
|
38
|
+
stack.append(reason)
|
|
39
|
+
self._reason_stack.set(stack)
|
|
40
|
+
|
|
41
|
+
def get_change_reason(self) -> str:
|
|
42
|
+
return " -> ".join(self._ensure_reason_stack())
|
|
43
|
+
|
|
44
|
+
def build_sql(self) -> str:
|
|
45
|
+
ctx = self._ensure_data().copy()
|
|
46
|
+
reason = self.get_change_reason()
|
|
47
|
+
if reason:
|
|
48
|
+
ctx["change_reason"] = reason
|
|
49
|
+
|
|
50
|
+
lines = []
|
|
51
|
+
for k, v in ctx.items():
|
|
52
|
+
key = f"session.myapp_{k}"
|
|
53
|
+
val = str(v).replace("'", "''")
|
|
54
|
+
lines.append(f"SET {key} = '{val}';")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def use(self, reset: bool = False, **kwargs):
|
|
59
|
+
from django.db import connection
|
|
60
|
+
|
|
61
|
+
original_data = self._ensure_data().copy()
|
|
62
|
+
ctx = original_data.copy()
|
|
63
|
+
ctx.update(kwargs)
|
|
64
|
+
self._data.set(ctx)
|
|
65
|
+
|
|
66
|
+
# SET session variables
|
|
67
|
+
sql = self.build_sql()
|
|
68
|
+
with connection.cursor() as cursor:
|
|
69
|
+
for stmt in sql.strip().split(";"):
|
|
70
|
+
if stmt.strip():
|
|
71
|
+
cursor.execute(stmt)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
self._data.set(original_data)
|
|
77
|
+
if reset:
|
|
78
|
+
with connection.cursor() as cursor:
|
|
79
|
+
for k in ctx.keys():
|
|
80
|
+
cursor.execute(f"RESET session.myapp_{k}")
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def use_change_reason(self, reason: str, reset: bool = False):
|
|
84
|
+
from django.db import connection
|
|
85
|
+
|
|
86
|
+
original_stack = self._ensure_reason_stack().copy()
|
|
87
|
+
stack = original_stack.copy()
|
|
88
|
+
stack.append(reason)
|
|
89
|
+
self._reason_stack.set(stack)
|
|
90
|
+
|
|
91
|
+
# SET session.myapp_change_reason
|
|
92
|
+
sql = self.build_sql()
|
|
93
|
+
with connection.cursor() as cursor:
|
|
94
|
+
for stmt in sql.strip().split(";"):
|
|
95
|
+
if stmt.strip():
|
|
96
|
+
cursor.execute(stmt)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
yield
|
|
100
|
+
finally:
|
|
101
|
+
self._reason_stack.set(original_stack)
|
|
102
|
+
if reset:
|
|
103
|
+
with connection.cursor() as cursor:
|
|
104
|
+
cursor.execute("RESET session.myapp_change_reason")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
audit_context = AuditContext()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def with_context(**kwargs):
|
|
111
|
+
def decorator(func: Callable):
|
|
112
|
+
@wraps(func)
|
|
113
|
+
def wrapper(*args, **fkwargs):
|
|
114
|
+
with audit_context.use(**kwargs):
|
|
115
|
+
return func(*args, **fkwargs)
|
|
116
|
+
|
|
117
|
+
return wrapper
|
|
118
|
+
|
|
119
|
+
return decorator
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def with_change_reason(reason: str):
|
|
123
|
+
def decorator(func: Callable):
|
|
124
|
+
@wraps(func)
|
|
125
|
+
def wrapper(*args, **kwargs):
|
|
126
|
+
with audit_context.use_change_reason(reason):
|
|
127
|
+
return func(*args, **kwargs)
|
|
128
|
+
|
|
129
|
+
return wrapper
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.utils.html import format_html
|
|
3
|
+
|
|
4
|
+
from auditrum.integrations.django.models import AuditLog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@admin.register(AuditLog)
|
|
8
|
+
class AuditLogAdmin(admin.ModelAdmin):
|
|
9
|
+
list_display = (
|
|
10
|
+
"changed_at",
|
|
11
|
+
"operation",
|
|
12
|
+
"table_name",
|
|
13
|
+
"object_id",
|
|
14
|
+
"user_id",
|
|
15
|
+
"source",
|
|
16
|
+
"request_id",
|
|
17
|
+
"linked_object",
|
|
18
|
+
)
|
|
19
|
+
list_filter = ("operation", "table_name", "source")
|
|
20
|
+
search_fields = ("object_id", "request_id", "change_reason")
|
|
21
|
+
readonly_fields = [f.name for f in AuditLog._meta.fields]
|
|
22
|
+
ordering = ("-changed_at",)
|
|
23
|
+
|
|
24
|
+
def linked_object(self, obj):
|
|
25
|
+
if obj.content_object:
|
|
26
|
+
return format_html(
|
|
27
|
+
'<a href="{}">{}</a>',
|
|
28
|
+
obj.content_object.get_absolute_url(),
|
|
29
|
+
obj.content_object,
|
|
30
|
+
)
|
|
31
|
+
return "-"
|
|
32
|
+
|
|
33
|
+
linked_object.allow_tags = True
|
|
34
|
+
linked_object.short_description = "Linked Object"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.db.models.signals import post_migrate
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PgAuditIntegrationConfig(AppConfig):
|
|
6
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
7
|
+
name = "auditrum.integrations.django"
|
|
8
|
+
label = "auditrum_django"
|
|
9
|
+
verbose_name = "PostgreSQL Audit (auditrum)"
|
|
10
|
+
|
|
11
|
+
def ready(self):
|
|
12
|
+
from .hooks import register_post_migrate_hook
|
|
13
|
+
from django.apps import apps
|
|
14
|
+
from importlib import import_module
|
|
15
|
+
|
|
16
|
+
post_migrate.connect(register_post_migrate_hook, sender=self)
|
|
17
|
+
|
|
18
|
+
# Automatically discover and import audit.py from all installed apps
|
|
19
|
+
for app_config in apps.get_app_configs():
|
|
20
|
+
try:
|
|
21
|
+
import_module(f"{app_config.name}.audit")
|
|
22
|
+
except ModuleNotFoundError:
|
|
23
|
+
continue
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict, Type
|
|
2
|
+
|
|
3
|
+
from django.db.models import Model
|
|
4
|
+
|
|
5
|
+
registry: Dict[Type[Model], Dict[str, Any]] = {}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register(
|
|
9
|
+
model_cls: Type[Model],
|
|
10
|
+
*,
|
|
11
|
+
track_only=None,
|
|
12
|
+
exclude_fields=None,
|
|
13
|
+
log_conditions=None,
|
|
14
|
+
meta_fields=None
|
|
15
|
+
) -> None:
|
|
16
|
+
if track_only and exclude_fields:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"Cannot define both `track_only` and `exclude_fields` on model {model_cls.__name__}"
|
|
19
|
+
)
|
|
20
|
+
registry[model_cls] = {
|
|
21
|
+
"table_name": model_cls._meta.db_table,
|
|
22
|
+
"fields": [f.name for f in model_cls._meta.get_fields() if f.concrete],
|
|
23
|
+
"track_only": track_only,
|
|
24
|
+
"exclude_fields": exclude_fields,
|
|
25
|
+
"log_conditions": log_conditions,
|
|
26
|
+
"meta_fields": meta_fields,
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import structlog
|
|
2
|
+
from django.db import connection
|
|
3
|
+
|
|
4
|
+
from auditrum.integrations.django.audit import registry
|
|
5
|
+
from auditrum.integrations.django.settings import audit_settings
|
|
6
|
+
from auditrum.triggers import generate_trigger_sql
|
|
7
|
+
|
|
8
|
+
log = structlog.get_logger()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_post_migrate_hook(sender, **kwargs):
|
|
12
|
+
with connection.cursor() as cursor:
|
|
13
|
+
for _model_cls, config in registry.items():
|
|
14
|
+
table_name = config["table_name"]
|
|
15
|
+
track_only = config["track_only"]
|
|
16
|
+
exclude_fields = config["exclude_fields"]
|
|
17
|
+
log_conditions = config["log_conditions"]
|
|
18
|
+
meta_fields = config["meta_fields"]
|
|
19
|
+
|
|
20
|
+
sql = generate_trigger_sql(
|
|
21
|
+
table_name=table_name,
|
|
22
|
+
track_only=track_only,
|
|
23
|
+
exclude_fields=exclude_fields,
|
|
24
|
+
log_conditions=log_conditions,
|
|
25
|
+
meta_fields=meta_fields,
|
|
26
|
+
audit_table=audit_settings.table_name,
|
|
27
|
+
)
|
|
28
|
+
log.info("Applying audit trigger", table=table_name)
|
|
29
|
+
cursor.execute(sql)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
from datetime import date
|
|
5
|
+
from django.core.management.base import BaseCommand
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from auditrum.schema import generate_auditlog_partitions_sql
|
|
8
|
+
import psycopg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Create partitions for the auditlog table N months ahead"
|
|
13
|
+
|
|
14
|
+
def add_arguments(self, parser):
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--months",
|
|
17
|
+
type=int,
|
|
18
|
+
default=1,
|
|
19
|
+
help="Number of months ahead to create partitions for (default: 1)",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def handle(self, *args, **options):
|
|
23
|
+
months = options["months"]
|
|
24
|
+
sql = generate_auditlog_partitions_sql(months_ahead=months)
|
|
25
|
+
self.stdout.write(f"Executing SQL to create partitions for {months} month(s)...")
|
|
26
|
+
|
|
27
|
+
from psycopg.conninfo import make_conninfo
|
|
28
|
+
|
|
29
|
+
db_config = settings.DATABASES["default"]
|
|
30
|
+
db_dsn = make_conninfo(
|
|
31
|
+
host=db_config.get("HOST") or "localhost",
|
|
32
|
+
port=db_config.get("PORT") or "5432",
|
|
33
|
+
dbname=db_config["NAME"],
|
|
34
|
+
user=db_config["USER"],
|
|
35
|
+
password=db_config["PASSWORD"],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
with psycopg.connect(db_dsn) as conn, conn.cursor() as cur:
|
|
39
|
+
cur.execute(sql)
|
|
40
|
+
self.stdout.write(self.style.SUCCESS("Partitions created successfully."))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
4
|
+
|
|
5
|
+
from auditrum.context import audit_context as ctx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RequestIDMiddleware(MiddlewareMixin):
|
|
9
|
+
def process_request(self, request):
|
|
10
|
+
request.request_id = str(uuid.uuid4())
|
|
11
|
+
ctx.set("request_id", request.request_id)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuditrumMiddleware(MiddlewareMixin):
|
|
15
|
+
def process_request(self, request):
|
|
16
|
+
user = getattr(request, "user", None)
|
|
17
|
+
session = getattr(request, "session", None)
|
|
18
|
+
|
|
19
|
+
ctx.set("user_id", getattr(user, "id", None))
|
|
20
|
+
ctx.set("username", getattr(user, "username", None))
|
|
21
|
+
ctx.set("client_ip", request.META.get("REMOTE_ADDR"))
|
|
22
|
+
ctx.set("user_agent", request.META.get("HTTP_USER_AGENT"))
|
|
23
|
+
ctx.set("session_key", getattr(session, "session_key", None))
|
|
24
|
+
ctx.set("request_id", str(uuid.uuid4()))
|
|
25
|
+
ctx.set("source", "http")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from django.db import migrations
|
|
2
|
+
|
|
3
|
+
from auditrum.integrations.django.settings import audit_settings
|
|
4
|
+
from auditrum.schema import (
|
|
5
|
+
generate_auditlog_table_sql,
|
|
6
|
+
generate_auditlog_partitions_sql,
|
|
7
|
+
generate_jsonb_diff_function_sql,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Migration(migrations.Migration):
|
|
12
|
+
initial = True
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.RunSQL(
|
|
17
|
+
sql="\n\n".join([
|
|
18
|
+
generate_auditlog_table_sql(audit_settings.table_name),
|
|
19
|
+
generate_jsonb_diff_function_sql(),
|
|
20
|
+
generate_auditlog_partitions_sql(audit_settings.table_name, months_ahead=3),
|
|
21
|
+
]),
|
|
22
|
+
reverse_sql=f"DROP TABLE IF EXISTS {audit_settings.table_name} CASCADE;",
|
|
23
|
+
),
|
|
24
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from apps.audit.models import AuditLog
|
|
2
|
+
from django.contrib.contenttypes.models import ContentType
|
|
3
|
+
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
|
4
|
+
from django.shortcuts import render
|
|
5
|
+
from django.urls import path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuditHistoryMixin:
|
|
9
|
+
def get_urls(self):
|
|
10
|
+
urls = super().get_urls()
|
|
11
|
+
custom_urls = [
|
|
12
|
+
path(
|
|
13
|
+
"<path:object_id>/history/",
|
|
14
|
+
self.admin_site.admin_view(self.object_history_view),
|
|
15
|
+
name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_audit_history",
|
|
16
|
+
),
|
|
17
|
+
]
|
|
18
|
+
return custom_urls + urls
|
|
19
|
+
|
|
20
|
+
def object_history_view(self, request, object_id):
|
|
21
|
+
obj = self.get_object(request, object_id)
|
|
22
|
+
content_type = ContentType.objects.get_for_model(self.model)
|
|
23
|
+
logs = AuditLog.objects.filter(
|
|
24
|
+
content_type=content_type, object_id=str(object_id)
|
|
25
|
+
).order_by("-changed_at")
|
|
26
|
+
|
|
27
|
+
paginator = Paginator(logs, 20)
|
|
28
|
+
page_number = request.GET.get("page", 1)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
page_obj = paginator.page(page_number)
|
|
32
|
+
except PageNotAnInteger:
|
|
33
|
+
page_obj = paginator.page(1)
|
|
34
|
+
except EmptyPage:
|
|
35
|
+
page_obj = paginator.page(paginator.num_pages)
|
|
36
|
+
|
|
37
|
+
context = dict(
|
|
38
|
+
self.admin_site.each_context(request),
|
|
39
|
+
title=f"History: {obj}",
|
|
40
|
+
object=obj,
|
|
41
|
+
opts=self.model._meta,
|
|
42
|
+
audit_logs=page_obj.object_list,
|
|
43
|
+
page_obj=page_obj,
|
|
44
|
+
is_paginated=True,
|
|
45
|
+
)
|
|
46
|
+
return render(request, "audit/object_history.html", context)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
2
|
+
from django.contrib.contenttypes.models import ContentType
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
from auditrum.integrations.django.settings import audit_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuditLog(models.Model):
|
|
9
|
+
operation = models.CharField(max_length=16)
|
|
10
|
+
changed_at = models.DateTimeField(auto_now_add=True)
|
|
11
|
+
content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.SET_NULL)
|
|
12
|
+
object_id = models.CharField(max_length=255)
|
|
13
|
+
content_object = GenericForeignKey("content_type", "object_id")
|
|
14
|
+
table_name = models.CharField(max_length=255)
|
|
15
|
+
user_id = models.IntegerField(null=True, blank=True)
|
|
16
|
+
old_data = models.JSONField(null=True, blank=True)
|
|
17
|
+
new_data = models.JSONField(null=True, blank=True)
|
|
18
|
+
diff = models.JSONField(null=True, blank=True)
|
|
19
|
+
meta = models.JSONField(null=True, blank=True)
|
|
20
|
+
request_id = models.CharField(max_length=255, null=True, blank=True)
|
|
21
|
+
change_reason = models.TextField(null=True, blank=True)
|
|
22
|
+
source = models.CharField(max_length=255, null=True, blank=True)
|
|
23
|
+
|
|
24
|
+
class Meta:
|
|
25
|
+
db_table = audit_settings.table_name
|
|
26
|
+
verbose_name = "Audit Log"
|
|
27
|
+
verbose_name_plural = "Audit Logs"
|
|
28
|
+
managed = False
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return f"{self.operation} on {self.table_name} ({self.object_id})"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuditSettings:
|
|
5
|
+
@property
|
|
6
|
+
def table_name(self) -> str:
|
|
7
|
+
return getattr(settings, "PGAUDIT_TABLE_NAME", "auditlog")
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def enabled(self) -> bool:
|
|
11
|
+
return getattr(settings, "PGAUDIT_ENABLED", True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
audit_settings = AuditSettings()
|