py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Comparing a row before a change against the values written to it.
|
|
2
|
+
|
|
3
|
+
The comparison is the hard part. The two sides come from different places - one read back
|
|
4
|
+
from Postgres, one handed in by the caller - and psycopg returns real Python types, so
|
|
5
|
+
neither `==` nor a blind `str()` is right. `Decimal("10.50")` meets the `10.5` a caller
|
|
6
|
+
passed, and a `date` meets its ISO string; calling either pair different reports a change on
|
|
7
|
+
a column nobody touched, which is how an audit log becomes something nobody opens.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
from decimal import Decimal, InvalidOperation
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
REDACTED = "***"
|
|
16
|
+
|
|
17
|
+
# Only consulted when at least one side is a real bool. That guard is load-bearing: without
|
|
18
|
+
# it a text column holding the literal "true" would silently equal one holding "1", and the
|
|
19
|
+
# trail would stop showing a change that really happened.
|
|
20
|
+
_TRUE = frozenset({"1", "t", "true", "y", "yes", "on"})
|
|
21
|
+
_FALSE = frozenset({"0", "f", "false", "n", "no", "off", ""})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _boolish(value: Any) -> str:
|
|
25
|
+
"""Reduce a value to "1"/"0" if it reads as a boolean, else to something that cannot
|
|
26
|
+
match either - so an unrecognised value still fails the comparison rather than being
|
|
27
|
+
guessed at."""
|
|
28
|
+
|
|
29
|
+
if isinstance(value, bool):
|
|
30
|
+
return "1" if value else "0"
|
|
31
|
+
|
|
32
|
+
if isinstance(value, int):
|
|
33
|
+
return "1" if value == 1 else ("0" if value == 0 else str(value))
|
|
34
|
+
|
|
35
|
+
text = str(value).strip().lower()
|
|
36
|
+
if text in _TRUE:
|
|
37
|
+
return "1"
|
|
38
|
+
|
|
39
|
+
if text in _FALSE:
|
|
40
|
+
return "0"
|
|
41
|
+
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _as_decimal(value: Any) -> Decimal | None:
|
|
46
|
+
if isinstance(value, bool):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
if isinstance(value, Decimal):
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
if isinstance(value, (int, float)):
|
|
53
|
+
return Decimal(str(value))
|
|
54
|
+
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
try:
|
|
57
|
+
return Decimal(value)
|
|
58
|
+
except (InvalidOperation, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_plain_date(value: Any) -> bool:
|
|
65
|
+
return isinstance(value, datetime.date) and not isinstance(value, datetime.datetime)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_datetime(value: Any, as_date: bool) -> datetime.datetime | datetime.date | None:
|
|
69
|
+
"""Parse to whichever of date/datetime the comparison is being made in.
|
|
70
|
+
|
|
71
|
+
Which one that is depends on the *other* operand: a `date` column comes back from
|
|
72
|
+
psycopg as a date, and its ISO string parsed as a datetime would be midnight on that
|
|
73
|
+
day - never equal to the date itself. So the pair decides the type, not each side
|
|
74
|
+
independently.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
if isinstance(value, datetime.datetime):
|
|
78
|
+
return value.date() if as_date else value
|
|
79
|
+
|
|
80
|
+
if isinstance(value, datetime.date):
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
try:
|
|
85
|
+
return datetime.date.fromisoformat(value) if as_date else datetime.datetime.fromisoformat(value)
|
|
86
|
+
except ValueError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def same(a: Any, b: Any) -> bool:
|
|
93
|
+
"""Whether two values represent the same stored content.
|
|
94
|
+
|
|
95
|
+
Deliberately not `==`. The two sides come from different places - one read back from
|
|
96
|
+
Postgres, one handed in by the caller - so a `date` legitimately meets its ISO string
|
|
97
|
+
and a `Decimal` meets an int.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
if a is None or b is None:
|
|
101
|
+
return a is None and b is None
|
|
102
|
+
|
|
103
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
104
|
+
return _boolish(a) == _boolish(b)
|
|
105
|
+
|
|
106
|
+
# Ordered before the numeric branch: datetime is not a number, but date arithmetic
|
|
107
|
+
# types would otherwise fall through to the string comparison and compare formatting
|
|
108
|
+
# rather than instants.
|
|
109
|
+
if isinstance(a, (datetime.datetime, datetime.date)) or isinstance(b, (datetime.datetime, datetime.date)):
|
|
110
|
+
as_date = _is_plain_date(a) or _is_plain_date(b)
|
|
111
|
+
left, right = _as_datetime(a, as_date), _as_datetime(b, as_date)
|
|
112
|
+
if left is None or right is None:
|
|
113
|
+
return str(a) == str(b)
|
|
114
|
+
|
|
115
|
+
if isinstance(left, datetime.datetime) and isinstance(right, datetime.datetime):
|
|
116
|
+
# A tz-aware value and a naive one cannot be ordered against each other, and
|
|
117
|
+
# guessing a timezone for the naive side would silently shift it.
|
|
118
|
+
if (left.tzinfo is None) != (right.tzinfo is None):
|
|
119
|
+
return str(a) == str(b)
|
|
120
|
+
|
|
121
|
+
return left == right
|
|
122
|
+
|
|
123
|
+
if isinstance(a, (dict, list, tuple, set)) or isinstance(b, (dict, list, tuple, set)):
|
|
124
|
+
return _json(a) == _json(b)
|
|
125
|
+
|
|
126
|
+
left_number, right_number = _as_decimal(a), _as_decimal(b)
|
|
127
|
+
if left_number is not None and right_number is not None:
|
|
128
|
+
return left_number == right_number
|
|
129
|
+
|
|
130
|
+
return str(a) == str(b)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _json(value: Any) -> str:
|
|
134
|
+
try:
|
|
135
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return str(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def mask(values: dict[str, Any], exclude: list[str]) -> dict[str, Any]:
|
|
141
|
+
"""Replace excluded values while keeping their keys.
|
|
142
|
+
|
|
143
|
+
The key stays so the trail still shows that a password changed; only the value is
|
|
144
|
+
withheld.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
if not exclude:
|
|
148
|
+
return values
|
|
149
|
+
|
|
150
|
+
return {key: (REDACTED if key in exclude else value) for key, value in values.items()}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def between(
|
|
154
|
+
before: dict[str, Any] | None,
|
|
155
|
+
after: dict[str, Any] | None,
|
|
156
|
+
exclude: list[str] | None = None,
|
|
157
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
158
|
+
"""Compare a row before and after, returning only what changed.
|
|
159
|
+
|
|
160
|
+
Four shapes: (None, after) is an insert, (before, None) a delete, (before, after) an
|
|
161
|
+
update reporting only differences, and (None, None) nothing.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
excluded = exclude or []
|
|
165
|
+
|
|
166
|
+
if before is None and after is None:
|
|
167
|
+
return (None, None)
|
|
168
|
+
|
|
169
|
+
if before is None:
|
|
170
|
+
return (None, mask(after or {}, excluded))
|
|
171
|
+
|
|
172
|
+
if after is None:
|
|
173
|
+
return (mask(before, excluded), None)
|
|
174
|
+
|
|
175
|
+
old_values: dict[str, Any] = {}
|
|
176
|
+
new_values: dict[str, Any] = {}
|
|
177
|
+
|
|
178
|
+
# Only keys present in `after` are considered. An update is routinely handed three
|
|
179
|
+
# columns against a thirty-column row, and reporting the other twenty-seven as changes
|
|
180
|
+
# to nothing is how a trail becomes unreadable.
|
|
181
|
+
for key, new_value in after.items():
|
|
182
|
+
# A key absent from `before` is a change from None - the case a naive dict diff
|
|
183
|
+
# drops silently, and exactly the shape of "this column was just populated".
|
|
184
|
+
old_value = before.get(key)
|
|
185
|
+
|
|
186
|
+
if same(old_value, new_value):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if key in excluded:
|
|
190
|
+
old_values[key] = REDACTED
|
|
191
|
+
new_values[key] = REDACTED
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
old_values[key] = old_value
|
|
195
|
+
new_values[key] = new_value
|
|
196
|
+
|
|
197
|
+
if not new_values:
|
|
198
|
+
# Collapses to a pair of Nones rather than empty dicts, which is what lets the
|
|
199
|
+
# caller read "nothing changed" without inspecting the contents.
|
|
200
|
+
return (None, None)
|
|
201
|
+
|
|
202
|
+
return (old_values, new_values)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AuditError(Exception):
|
|
2
|
+
"""A refusal or a failed write in the audit trail.
|
|
3
|
+
|
|
4
|
+
Raised rather than logged when `strict` is on, which is the default: an audit trail
|
|
5
|
+
that quietly stops recording is worse than one that is obviously broken, because the
|
|
6
|
+
gap is only discovered when somebody goes looking for the row that should have been
|
|
7
|
+
there.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The event, and the ambient context it is filled in from.
|
|
2
|
+
|
|
3
|
+
The request id has to be per-task, which is why it is a ContextVar the bridge sets per
|
|
4
|
+
request rather than anything process-wide. A single server process handles many requests at
|
|
5
|
+
once, so a shared id would make "everything that happened in this request" return somebody
|
|
6
|
+
else's changes alongside your own - and the trail would look correct while saying something
|
|
7
|
+
false.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import contextvars
|
|
12
|
+
import secrets
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
CREATED = "created"
|
|
18
|
+
UPDATED = "updated"
|
|
19
|
+
DELETED = "deleted"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Actor:
|
|
24
|
+
type: str = ""
|
|
25
|
+
id: str = ""
|
|
26
|
+
name: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class RequestContext:
|
|
31
|
+
request_id: str = ""
|
|
32
|
+
url: str = ""
|
|
33
|
+
ip_address: str = ""
|
|
34
|
+
user_agent: str = ""
|
|
35
|
+
actor: Actor = field(default_factory=Actor)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Defaulted to None rather than to an empty RequestContext. The instance would be shared by
|
|
39
|
+
# every task that never entered request_context(), and while this one is frozen, a default
|
|
40
|
+
# that is an object at all is the shape that goes wrong the moment somebody makes it
|
|
41
|
+
# mutable.
|
|
42
|
+
_context: contextvars.ContextVar[RequestContext | None] = contextvars.ContextVar(
|
|
43
|
+
"py_app_runner_audit_context", default=None
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_EMPTY = RequestContext()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def new_request_id() -> str:
|
|
50
|
+
"""32 hex characters. Groups every change made during one request or CLI run."""
|
|
51
|
+
|
|
52
|
+
return secrets.token_hex(16)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def current_context() -> RequestContext:
|
|
56
|
+
"""An empty context outside any request, so recording never depends on having entered
|
|
57
|
+
one - a CLI command that audits a change still writes a row, just without an actor."""
|
|
58
|
+
|
|
59
|
+
return _context.get() or _EMPTY
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_context(context: RequestContext) -> contextvars.Token[RequestContext | None]:
|
|
63
|
+
return _context.set(context)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@contextlib.contextmanager
|
|
67
|
+
def request_context(
|
|
68
|
+
actor: Actor | None = None,
|
|
69
|
+
url: str = "",
|
|
70
|
+
ip_address: str = "",
|
|
71
|
+
user_agent: str = "",
|
|
72
|
+
request_id: str | None = None,
|
|
73
|
+
) -> Iterator[RequestContext]:
|
|
74
|
+
"""Establish the ambient context for one request, or one CLI run.
|
|
75
|
+
|
|
76
|
+
The bridge enters this per request; a command enters it once for the whole command. The
|
|
77
|
+
token is reset on the way out so a task that runs after this one does not inherit it.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
context = RequestContext(
|
|
81
|
+
request_id=request_id if request_id is not None else new_request_id(),
|
|
82
|
+
url=url,
|
|
83
|
+
ip_address=ip_address,
|
|
84
|
+
user_agent=user_agent,
|
|
85
|
+
actor=actor or Actor(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
token = _context.set(context)
|
|
89
|
+
try:
|
|
90
|
+
yield context
|
|
91
|
+
finally:
|
|
92
|
+
_context.reset(token)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class AuditEvent:
|
|
97
|
+
event: str
|
|
98
|
+
entity_type: str
|
|
99
|
+
entity_id: str = ""
|
|
100
|
+
module: str = ""
|
|
101
|
+
old_values: dict[str, Any] | None = None
|
|
102
|
+
new_values: dict[str, Any] | None = None
|
|
103
|
+
actor_type: str = ""
|
|
104
|
+
actor_id: str = ""
|
|
105
|
+
actor_name: str = ""
|
|
106
|
+
request_id: str = ""
|
|
107
|
+
url: str = ""
|
|
108
|
+
ip_address: str = ""
|
|
109
|
+
user_agent: str = ""
|
|
110
|
+
tags: list[str] = field(default_factory=list)
|
|
111
|
+
context: dict[str, Any] | None = None
|
|
112
|
+
created_at: Any = None
|
|
113
|
+
|
|
114
|
+
def with_resolved(self, context: RequestContext) -> "AuditEvent":
|
|
115
|
+
"""Fill in only the fields that are still empty.
|
|
116
|
+
|
|
117
|
+
A caller that named the actor explicitly - an import recording who requested it
|
|
118
|
+
rather than whoever happens to be logged in - keeps what it passed.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
return replace(
|
|
122
|
+
self,
|
|
123
|
+
actor_type=self.actor_type or context.actor.type,
|
|
124
|
+
actor_id=self.actor_id or context.actor.id,
|
|
125
|
+
actor_name=self.actor_name or context.actor.name,
|
|
126
|
+
request_id=self.request_id or context.request_id,
|
|
127
|
+
url=self.url or context.url,
|
|
128
|
+
ip_address=self.ip_address or context.ip_address,
|
|
129
|
+
user_agent=self.user_agent or context.user_agent,
|
|
130
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The single writer.
|
|
2
|
+
|
|
3
|
+
One INSERT per event, on the cursor the caller handed in, inside whatever transaction the
|
|
4
|
+
caller already opened. No buffering, no batching, no flush on shutdown - that is not an
|
|
5
|
+
omission, it is the whole guarantee: a rolled-back change takes its audit row with it, and
|
|
6
|
+
anything that defers the write gives that up.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
from psycopg import sql
|
|
16
|
+
|
|
17
|
+
from py_app_runner.audit.errors import AuditError
|
|
18
|
+
from py_app_runner.audit.event import AuditEvent
|
|
19
|
+
|
|
20
|
+
# The audit table is an identifier, so it cannot be bound as a parameter. Identifier()
|
|
21
|
+
# quotes it, but a resolver is application code that may have been handed a value derived
|
|
22
|
+
# from a request, so the shape is checked before it gets there.
|
|
23
|
+
_TABLE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
|
|
24
|
+
|
|
25
|
+
# Values are cut to fit rather than rejected. An audit write that throws because somebody's
|
|
26
|
+
# name is long has turned the trail into an outage.
|
|
27
|
+
_WIDTHS = {
|
|
28
|
+
"request_id": 32,
|
|
29
|
+
"module": 64,
|
|
30
|
+
"event": 32,
|
|
31
|
+
"entity_type": 128,
|
|
32
|
+
"entity_id": 64,
|
|
33
|
+
"actor_type": 32,
|
|
34
|
+
"actor_id": 64,
|
|
35
|
+
"actor_name": 190,
|
|
36
|
+
"ip_address": 45,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_COLUMNS = (
|
|
40
|
+
"created_at",
|
|
41
|
+
"request_id",
|
|
42
|
+
"module",
|
|
43
|
+
"event",
|
|
44
|
+
"entity_type",
|
|
45
|
+
"entity_id",
|
|
46
|
+
"actor_type",
|
|
47
|
+
"actor_id",
|
|
48
|
+
"actor_name",
|
|
49
|
+
"old_values",
|
|
50
|
+
"new_values",
|
|
51
|
+
"url",
|
|
52
|
+
"ip_address",
|
|
53
|
+
"user_agent",
|
|
54
|
+
"tags",
|
|
55
|
+
"context",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def assert_table_name(table: str) -> str:
|
|
60
|
+
if _TABLE_RE.match(table) is None:
|
|
61
|
+
raise AuditError(f"Refusing to write the audit trail to {table!r}: not a plain table name")
|
|
62
|
+
|
|
63
|
+
return table
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def qualified(table: str) -> sql.Composed:
|
|
67
|
+
"""Quote a schema qualifier as two identifiers.
|
|
68
|
+
|
|
69
|
+
Identifier("public.audit_log") quotes the dot into the name, producing a single
|
|
70
|
+
relation that does not exist.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
return sql.SQL(".").join(sql.Identifier(part) for part in table.split("."))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fit(column: str, value: str) -> str:
|
|
77
|
+
width = _WIDTHS.get(column)
|
|
78
|
+
if width is None or len(value) <= width:
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
return value[:width]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _json(value: Any) -> str | None:
|
|
85
|
+
if value is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# ensure_ascii off so a name keeps its diacritics rather than becoming escapes
|
|
90
|
+
# nobody can read in a SELECT; default=str so a Decimal or a datetime in a payload
|
|
91
|
+
# is recorded rather than failing the whole write.
|
|
92
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
# The change itself already happened. Losing the detail of it is bad; failing the
|
|
95
|
+
# request over an unserialisable value in a context dict is worse.
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Store:
|
|
100
|
+
def __init__(self, table: str = "audit_log") -> None:
|
|
101
|
+
self.table = assert_table_name(table)
|
|
102
|
+
|
|
103
|
+
async def write(self, cur: psycopg.AsyncCursor, event: AuditEvent) -> None:
|
|
104
|
+
statement = sql.SQL("INSERT INTO {rel} ({cols}) VALUES ({vals})").format(
|
|
105
|
+
rel=qualified(self.table),
|
|
106
|
+
cols=sql.SQL(", ").join(sql.Identifier(c) for c in _COLUMNS),
|
|
107
|
+
vals=sql.SQL(", ").join(sql.Placeholder() for _ in _COLUMNS),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
await cur.execute(statement, self.row(event))
|
|
111
|
+
|
|
112
|
+
def row(self, event: AuditEvent) -> tuple[Any, ...]:
|
|
113
|
+
created_at = event.created_at or datetime.datetime.now(datetime.UTC)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
created_at,
|
|
117
|
+
_fit("request_id", event.request_id),
|
|
118
|
+
_fit("module", event.module),
|
|
119
|
+
_fit("event", event.event),
|
|
120
|
+
_fit("entity_type", event.entity_type),
|
|
121
|
+
_fit("entity_id", event.entity_id),
|
|
122
|
+
_fit("actor_type", event.actor_type),
|
|
123
|
+
_fit("actor_id", event.actor_id),
|
|
124
|
+
_fit("actor_name", event.actor_name),
|
|
125
|
+
_json(event.old_values),
|
|
126
|
+
_json(event.new_values),
|
|
127
|
+
event.url,
|
|
128
|
+
_fit("ip_address", event.ip_address),
|
|
129
|
+
event.user_agent,
|
|
130
|
+
# An empty tag list stores NULL rather than "[]", so "has tags" is a NULL check
|
|
131
|
+
# rather than a json length.
|
|
132
|
+
_json(event.tags) if event.tags else None,
|
|
133
|
+
_json(event.context),
|
|
134
|
+
)
|
|
File without changes
|